Lib.php

<?php

namespace Tlf\User;

class Lib {

    use Throttle;

    /**
     * Expects `web_address = http://example.com`
     * Expects `email_from = help@example.com`
     *
     */
    public $config = [];

    /** valid symbol characters for a password */
    public string $password_symbols = '~`!@#$%^&*()_-+={[]}|\\:;"\'<,>.?/';
    /** max len should not be changed, bc bcrypt limit is 72 chars */
    public int $max_len = 72;
    public int $min_len = 8;
    public bool $require_num = true;
    public bool $require_symbol = true;
    public bool $require_mix_case = true;
    /** the class of the user to instantiate */
    public string $user_class = '\\Tlf\\User';

    public \PDO $pdo;

    /**
     * to disable a page just add its page identifier to this array. One of 'login', 'register', 'reset-password', 'logout', or 'terms'
     */
    public $disabled_pages = [];

    /** 
     * This should only be set after a session is validated
     * @key the csrf token name
     * @value true, always true
     */
    public array $valid_sessions = [];
    /**
     *
     * @key the key_prefix
     * @value the actual key (with uniqid)
     */
    public array $latest_csrf = [];

    /**
     * Array of query strings identifiable by key. Generated by LilSql (of LilDb package)
     */
    public array $queries = [];

    public function __construct($pdo){
        $this->pdo = $pdo;
        $this->queries = unserialize(file_get_contents(__DIR__.'/../db/serialized.txt'));
    }

    public function is_post():bool{
        if ($_SERVER['REQUEST_METHOD']=='POST')return true;
        return false;
    }

    public function init_db(){
        $pdo = $this->pdo;
        $errmode = $pdo->getAttribute(\PDO::ATTR_ERRMODE);
        $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
        $sql = file_get_contents(dirname(__DIR__).'/db/create.sql');
        $pdo->exec($sql);
        $pdo->setAttribute(\PDO::ATTR_ERRMODE, $errmode);

        // if ($pdo->errorCode()!='00000'){
            // print_r($err);
            // throw new \PDOException()
        // }
    }

    /** 
     * Checks if a page is disabled in `$this->disabled_pages` and outputs a message if so
     *
     * @output a message that the page is disabled
     * @return true/false 
     */
    public function page_is_disabled(string $page_id){
        // print_r($this->disabled_pages);
        // var_dump($page_id);
        // exit;
        if (!in_array($page_id, $this->disabled_pages))return false;

        if (!headers_sent()){
            header('HTTP/1.1 403 Forbidden', 403);
        }
        echo "\n<h1>Page Disabled</h1>\n<p>You are not allowed to access this page</p>\n";
        return true;

        // $url = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
        // $parts = explode('/',$url);
        // $size = count($parts);
        // if ($parts[$size-1]=='')$size--;
        // $rel_url = '/'.$parts[$size-1];
        // if (!in_array($rel_url, $this->disabled_pages)){
            // return true;
        // }
//
        // echo "\n<h1>Page Disabled</h1>\n<p>You are not allowed to access this page</p>\n";
//
        // return false;
    }

    /**
     * delete an entry in `role_permission` table
     */
    public function role_deny(string $role, string $permission){
        $stmt = $this->pdo->prepare($this->queries['role.deny']);
        $stmt->execute(['role'=>$role, 'permission'=>$permission]);

    }
    /**
     * Delete all entries with given role from `role_permission` and `user_role` tables
     */
    public function role_delete(string $role){
        $stmt = $this->pdo->prepare($this->queries['role.delete']);
        $stmt->execute(['role'=>$role]);

        // var_dump($stmt->rowCount());
        // print_r($this->pdo->errorInfo());
        // exit;
    }
    /**
     * add entry to `role_permission` table
     */
    public function role_allow(string $role, string $permission){
        $stmt = $this->pdo->prepare($this->queries['role.allow']);
        $stmt->execute(['role'=>$role, 'permission'=>$permission]);

        // var_dump($stmt->rowCount());
        // print_r($this->pdo->errorInfo());
        // exit;
    }

    /**
     * @return true/false whether password meets requirements or not
     */
    public function is_password_valid(string $password){
        $len = strlen($password);
        if ($len < $this->min_len || $len > $this->max_len)return false;

        if ($this->require_num&&!preg_match('/\d/', $password))return false;

        if ($this->require_mix_case&&
            (!preg_match('/[a-z]/', $password) ||
                !preg_match('/[A-Z]/', $password)
            )
        ) return false;

        // echo 'len & digits';
        // exit;

        $symbols = $this->password_symbols; 
        $reg = preg_quote($symbols, '/');
        if ($this->require_symbol&&!preg_match("/[$reg]/", $password))return false;

        return true;
    }

    
    /** 
     * get a user by their email. User may or may not be in the database/registered/active 
     * @return a user object (always, regardless of user existing in database)
     */
    public function user_from_email(string $email){
        $class = $this->user_class;
        $user = new $class($this->pdo);
        $user->email = $email;

        $pdo = $this->pdo;
        $stmt = $pdo->prepare($this->queries['user.from_email']);
        $stmt->execute(['email'=>$email]);
        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
        if (count($rows)!==1)return $user;
        $user->from_row($rows[0]);
        return $user;
    }


    /**
     * Login a user by validating the cookie sent with their request
     * @note for testing set $_COOKIE['taeluf_login'] = $code prior to calling this function
     * @param $cookie optional cookie code to use. else uses $_COOKIE['taeluf_login']
     *
     * @return user object if succesful, false otherwise
     */
    public function user_from_cookie($cookie=null) {
        // $code = $_COOKIE[$this->cookie_name] ?? null;
        $code = $cookie ?? $_COOKIE[\Tlf\User::$cookie_name]??null;
        if ($code==null)return false;
        $stmt = $this->pdo->prepare($this->queries['user.from_cookie']);
        $stmt->execute(['code'=>$code]);
        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);

        if (count($rows)!==1)return false;
        $row = $rows[0];
        if ($row['is_active']!=true)return false;

        $class = $this->user_class;
        $user = new $class($this->pdo);
        $user->email = $row['email'];
        $user->is_logged_in = true;
        $user->id = $row['id'];

        return $user;
    }


    ////////////
    // csrf
    ////////////

    public function make_csrf_code(){
        // this code from symfony csrf package: https://github.com/symfony/security-csrf/blob/5.4/TokenGenerator/UriSafeTokenGenerator.php
        $bytes = random_bytes(64);

        return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
    }

    /**
     *
     *
     * @param $key_prefix string to help identify your csrf token.
     * @param $expiry_minutes number of minutes the token should be valid for
     * @param $url the url path the token should be validated on, like '/some/url/'. If not set, it works on any path
     *
     * @return the csrf key. To load csrf data do `$_SESSION[$csrf_key]`. `$csrf_key` will be like `key_prefix-csrf-uniqid()`
     */
    public function enable_csrf(string $key_prefix='',int $expiry_minutes=60, string $url_path=''){
        $key = $key_prefix.'-csrf-'.uniqid();
        $data = [
            'code'=> $this->make_csrf_code(), 
            'expires_at' => time() + $expiry_minutes * 60,
            'uri' => $url_path,
        ];
        if (session_status()==PHP_SESSION_NONE)session_start();
        if (session_status()!=PHP_SESSION_ACTIVE)throw new \Exception("Failed to start session. Cannot do csrf without session.");
        $_SESSION[$key] = $data;
        $this->latest_csrf[$key_prefix] = $key;

        // error_log('csrf key: '.$key);
        return $key;
    }

    /** 
     * get the key of the csrf data in `$_POST` for the given key
     * @param $key_prefix see csrf_is_valid
     */
    public function get_csrf_post_key(string $key_prefix=''): string {
        $len = strlen($key_prefix) + strlen('-csrf-');
        foreach ($_POST as $key=>$value){
            if (substr($key,0,$len)!=$key_prefix.'-csrf-')continue;
            $post_key = $key;
            // $post_code = $value;
            return $post_key;
            // break;
        }
        return '';
    }

    public function get_csrf_session_key(string $key_prefix=''): string {
        if (isset($this->latest_csrf[$key_prefix]))return $this->latest_csrf[$key_prefix];
        $len = strlen($key_prefix) + strlen('-csrf-');
        foreach ($_SESSION as $key=>$value){
            if (substr($key,0,$len)!=$key_prefix.'-csrf-')continue;
            return $key;
        }
        return '';
    }

    public function get_csrf_session_input(string $key_prefix=''): string {
        $key = $this->get_csrf_session_key($key_prefix);
        $code = $_SESSION[$key]['code'];
        return '<input type="hidden" name="'.$key.'" value="'.$code.'">';
    }

    /**
     * Checks `$_POST` for the csrf token
     *
     * @param $key_prefix the same key prefix you passed to `$this->enable_csrf()`
     * @return true/false
     */
    public function csrf_is_valid(string $key_prefix=''): bool {
        // this attempts to do the checks listed on https://www.taeluf.com/blog/php/security/csrf-validation/

        $post_key = $this->get_csrf_post_key($key_prefix);
        if ($post_key=='')return false;
        $post_code = $_POST[$post_key];
        // because i unset from $_SESSION
        if (isset($this->valid_sessions[$post_key]))return true;

        if (session_status()==PHP_SESSION_NONE)session_start();
        if (session_status()!=PHP_SESSION_ACTIVE)throw new \Exception("Failed to start session. Cannot do csrf without session.");

        if (!isset($_SESSION[$post_key]))return false;
        $session_csrf = $_SESSION[$post_key];
        if ($session_csrf['code'] != $post_code) return false;
        if ($session_csrf['expires_at'] < time()) return false;
        $post_path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

        if ($session_csrf['uri'] != ''
            &&$session_csrf['uri'] != $post_path
        )return false;
        if (!isset($_SERVER['HTTP_REFERER']))return false;
        $referer_domain = parse_url($_SERVER['HTTP_REFERER'], PHP_URL_HOST);
        // to remove the port (mainly bc of localhost testing)
        $server_host = parse_url($_SERVER['HTTP_HOST'], PHP_URL_HOST);
        if ($server_host==null)$server_host = $_SERVER['HTTP_HOST'];

        if ($referer_domain != $server_host)return false;

        unset($_SESSION[$post_key]);
        $this->valid_sessions[$post_key] = true;
        return true;
    }

    public function security_consent_box(){
        echo "
            <label>
                Your IP address and browser's user agent will be logged with the submitted email address & will be viewable by the owner of the account & website administators.<br>
            &nbsp;<input type=\"checkbox\" name=\"logs_consent\" required>
                I consent to security logging<br>
            </label>
        ";
    }

}